Cursor is a fantastic tool for developing with AI, but it’s also a wonderful tool for interacting with any folder of files with AI. I use it to search through and chat with a folder of 1000s of Markdown files (downloaded from my Notion bookmarks database). Cursor indexes the files with its vector index, can read the file contents, summarize multiple files, and link to the source files – it’s changed how I interact with my bookmarks.
So when Cursor added MCP support – I was very excited. Instead of opening a Cursor window pointed at my bookmarks folder, I could build my own MCP server that indexed the bookmarks, and provide tools so that local bookmark search was available in all of my Cursor windows. I’ve successfully nerd-sniped myself into the MCP abyss for the foreseeable weekends.
Just gimme the code
Ok ok! Here’s my barebones MCP server in Swift that uses loopwork-ai/mcp-swift-sdk
.
MCP in Swift
It’s not hard to find example MCP servers, it’s a bit harder to find MCP servers that work, and it’s very hard to find MCP servers written in Swift. I found two SDKs on Github that look promising, the first one I found is https://github.com/gsabran/mcp-swift-sdk, and the second is the similarly-named-but-different https://github.com/loopwork-ai/mcp-swift-sdk. Both are under active development, so by the time you read this the ‘best’ one of these two may change, or even have more libraries available on Github.
I started with gsabran
‘s repo, and now I’m happier with loopwork-ai
‘s repository. Maybe it’s because their repo is better, or maybe it’s because I understand things better now, who could say?
A big benefit of loopwork-ai
‘s repo – it also contains a fully functional Mac app that attaches to the MCP server. So how does that work? what is an “MCP server” anyway?
MCP Architecture
Servers need to accept input requests and provide responses somehow. MCP is based on JSON-RPC v2.0, which makes the format of its input and output very clear. The MCP specification gives clear definitions for the various MCP specific messages.
When I hear the word “server,” I think of HTTP servers like Apache or Nginx running on port 80, or 443 for SSL, or 8080, or etc etc. However, the recommended and most common MCP server runs on stdio
input and output streams. Huh?! It’s not how I typically think of a server. The specification does also define SSE – an MCP protocol on HTTP, though the spec says that that “Clients SHOULD support stdio
whenever possible.”
I suspect this is motivated as much by security and authentication as it is by simplicity. If clients spawn the MCP process locally and communication through stdin
and stdout
, there’s a much smaller surface area for bad actors. If I instead run a small HTTP server locally on my Mac – that’s a much bigger potential hole in my security, both by accidentally exposing the MCP outside my device, as well as any security holes in the HTTP server I use.
At the end of the day, clients and servers are just trading JSON back and forth, so custom transports are entirely possible.
Specification Summary
Very simply, MCP servers provide prompts, resources, and tools. Clients connect to servers and discover what’s provided, and then surface those to the LLM and/or user as they see fit. The easiest way to think of an MCP server is a bag of functions – servers list the functions (tools) to the client, and the client calls those functions with some input.
Importantly, the MCP server does not see the context of the chat with the LLM – it only gets the parameters sent for a specific tool invocation. Clients are encouraged to verify with the user before calling a tool, so this means minimal context is sent to an MCP server, and all tools calls ideally go through human approval to mitigate the risk of leaking sensitive data.
Authentication
The MCP specification does not specify authentication, just like HTTP. Any authorization that you want to enforce for a client/server session you must handle yourself. For stdio
servers, you can track state in your running server without issue, as its only ever connected to a single client. Since it’s using input/output streams, there’s generally no authentication for stdio connections which is much easier.
For SSE
servers, you’ll need to handle authentication yourself, potentially with HTTP headers or something similar, potentially with a Bearer
token header using OAuth, etc.
SSE Transport
A quick primer on the SSE protocol – while the stdio transport allows only a single client to connect to the single server, an SSE enabled MCP server can potentially serve multiple clients. It does this by exposing a single GET endpoint. When a new connection is made, the server immediately sends a POST endpoint for that client to send requests to. Since all clients are using the same GET request, the server is responsible for separating out which requests belong to which GET connection. One strategy would be to use a separate POST endpoint per connection.
This discussion in the modelcontextprotocol/specification Github is a deep dive on the tradeoffs of stateful and stateless servers and possible future changes to allow for traditional stateless HTTP architecture, or using connections like WebSockets instead of long-lived GET.
Debugging Tools
The MCP documentation lists an number of helpful debugging tools.
- MCP Inspector: This is a small server that runs on your machine and can launch and connect to MCP servers. I strongly suggest getting your tool working with this inspector before moving on to Claude or Cursor integration.
- Claude Desktop Developer Tools: Claude itself can be helpful for debugging your server as you develop. Add your server into
claude_desktop_config.json
and restart Claude. Thentail
logs at
tail -f /Users/{username}/Library/Logs/Claude/mcp-server-{yourservername}.log - Server logs: I use Swift Logging for my server, as does the
loopwork-ai/mcp-swift-sdk
, which makes seeing logs in Xcode’s console very simple. The MCP specification usesstdin
andstdout
, so anything you send tostderr
will generally show up in the client’s logs somewhere. - Xcode: After launching the client (which then spawns the server), I connect the debugger to it and can then use all of the normal tools in Xcode: breakpoints, logging, etc. I also use the built executables path (Xcode → Product → Show Build Folder) as the server in Claude/Cursor so that I can rebuild in Xcode → relaunch Claude and see the new version.
MCP Tools Walkthrough
When the server initializes, it tells the connecting client what capabilities it has, and for the tool
capability there’s an option to notify when those tools change:
{ "capabilities": { "tools": { "listChanged": true } } }
Once the client hears the capabilities, it requests the tool list from the server. From what I’ve seen, clients are not very well behaved, and will ask for tools, prompts, and resources regardless of the capabilities described by the server. The specification for requesting the tool list is a request from the client:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "cursor": "optional-cursor-value" } }
The server would reply with its list of tools according to the specification. This example shows a helloPerson
tool that accepts a name:
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "helloPerson", "description": "Returns a friendly greeting message", "inputSchema": { "type": "object", "properties": { "name": [ "type": "string", "description": "Name of the person to say hello to", ] }, "required": ["name"] } } ], "nextCursor": "next-page-cursor" } }
At this point, the client has all of the information it needs to start calling the function:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "helloPerson", "arguments": { "name": "Adam" } } }
And the server can send its response to stdout
. Note that the response contains the id
of the request that it is responding to. This allows multiple requests to be in-flight, and the client can match responses with the corresponding request:
{ "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "Hello, Adam!" } ], "isError": false } }
Gotchas
stdio Format
Note that all of the JSON examples in this post and in the documentation are formatted for humans, but aren’t verbatim allowed responses. All server responses must not contain any newlines, and must be separated by a single newline.
stdin
is reserved for input RPC requests, and stdout
is reserved for RPC responses. Nothing else can be sent on those streams – any other logging must be sent on stderr
. Those print()
statements you use when debugging? Gotta find another way! I’ve been using Swift Logging instead.
Tools Changed Notification
The MCP specification allows for servers to notify clients when the list of tools updates. When the client hears this notification, it should send a new list/tools
request to get the new list of available tools.
{ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }
Once I had a barebones MCP server working with Claude, I spent quite a bit of time trying to diagnose why it wasn’t responding to my updated tools notification. Welp, it turns out Claude doesn’t support updating tools yet, and it appears Cursor doesn’t either.
Making it more confusing, the modelcontextprotocol/inspector does not show tools/list_changed
notifications either. After some digging, I found that only a few notification types will appear in the UI. So when you work on your MCP and can’t seem to get this notification to work – don’t worry, it’s [possibly] not you. It seems we’re so early in MCP ecosystem that tool list updates just aren’t widely support yet. The workaround? Restart your client 😬.
List Tools
Tools are the meat and potatoes of MCP servers. The client requests available tools from the server with the tools/list
method request, which accepts a single optional cursor parameter. When implementing Codable
in Swift, be careful when decoding optional parameters. When I began using loopwork-ai
‘s repository, there was a subtle decoding issue, and to show the issue, take a look at possible request JSON’s from a client:
// Specification for the List Tools requests to the server { "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "cursor": "optional-cursor-value" } }
Note that the cursor parameter is optional, so which of the following is a valid request? All of them? Probably? The right answer is “whatever the client that you need to integrate with sends you…”
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "cursor": null } } { "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} } { "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": null } { "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
When I started working with loopwork-ai
‘s sdk, the decoding was a bit too strict and failed to decode this message correctly. I’ve submitted a PR for the fix to make it more lenient, and have a branch on my mcp-swift-sdk
fork that I use for my development with a few other niceties.
Follow Along
I’m working on a barebones template for building MCP servers in Swift on the Mac. I’ve started a repository at adamwulf/mcp-template
which uses loopwork-ai
‘s SDK. My goal is to provide a simple EasyMCP
server that manages the MCP server lifecyle, provides easy tool registration, and provides an App Store safe way to ship an MCP command line tool that connects to a Mac app for its functionality.
So far, I have a simple wrapper around the loopwork-ai
SDK that handles the MCP stdio
transport setup and provides a simple tool registration method:
// Build the MCP server with logging let logger = Logger(label: "com.milestonemade.easymcp") let mcp = EasyMCP(logger: logger) // Register a simple tool that accepts a single parameter. // The name, description, and inputSchema will be sent to // the client so that the LLM knows what the tool does. try await mcp.register(tool: Tool( name: "helloPerson", description: "Returns a friendly greeting message", inputSchema: [ "type": "object", "properties": [ "name": [ "type": "string", "description": "Name of the person to say hello to", ] ], "required": ["name"] ] )) { input in // It's an async closure, so you can await whatever you need to for long running tasks await someOtherAsyncStuffIfYouWant() // Return your result and flag if it is/not an error return Result(content: [.text(hello(input["name"]?.stringValue ?? "world"))], isError: false) } try await mcp.start() try await mcp.waitUntilComplete()
It’s Alive!
After two weekends of fiddling, I have the mcpexample
command line server in mcp-template
working in Claude and Cursor! 🎉